feat(pow): add relay-load-aware adaptive PoW difficulty - #756
feat(pow): add relay-load-aware adaptive PoW difficulty#756Priyanshubhartistm wants to merge 4 commits into
Conversation
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
🦋 Changeset detectedLatest commit: e454e26 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
phoenix-server
left a comment
There was a problem hiding this comment.
Review: request changes
Independent two-axis review (correctness/security + conventions). The feature design is sound and well-documented (soft per-worker gate, opt-in, disabled by default, changeset + CONFIGURATION.md present), CI is green, and the tests pin real boundaries. But the core mapping does not do what its name, docs, and defaults promise, and two integration points were missed. Details below; the two blocking items are #1 and #2.
Blocking
1. The difficulty mapping is mis-scaled ~87x at defaults. recordEvent feeds each event to calculateEWMA with step=1 and no time normalization, so rate is a recency-weighted event count (steady state at R sustained events/sec is R * periodMs/1000/ln(2), about 86.6 * R at the default 60000 ms half-life), but getCurrentDifficulty compares it directly against targetEventsPerSecond, a per-second rate. Simulated with the PR code and shipped defaults (target 50, floor 0, ceiling 24): 0.5 sustained events/sec → difficulty 0; 1 event/sec → 18 bits; 2 events/sec → pinned at the 24-bit ceiling. The docs promise the ceiling at 2x target = 100 events/sec. On any live relay this degenerates into a permanent 24-bit requirement (~16.7M hashes/event). The tests pass only because they use same-instant bursts and tiny targets. Suggested fix: normalize before comparing (eps = rate / (periodMs / 1000 / Math.LN2)), or rename the setting to a per-EWMA-window unit, fix the default and docs to match, and add a unit test with time-spaced events (fake timers, one per second) pinning floor-at-target.
2. No floor clamp / no semantic config validation. getCurrentDifficulty clamps at ceilingBits but never at floorBits, and validateSettings only shape-checks against default-settings.yaml. With floorBits: 20, ceilingBits: 10: difficulty 10 at rate 55, 0 at rate 150, -10 at rate 200 — since the check is pow < requiredBits, a negative requirement never rejects, so PoW silently disables exactly when the relay is loaded. Suggested fix: Math.max(config.floorBits, Math.min(config.ceilingBits, scaled)), plus semantic validation (0 <= floorBits <= ceilingBits, periodMs > 0, targetEventsPerSecond > 0) in validateSettings.
Major
3. NIP-11 metadata not updated (src/handlers/request-handlers/root-request-handler.ts, outside this diff): min_pow_difficulty is advertised from the static minLeadingZeroBits only, and restricted_writes is computed from the static bits only. With adaptive enabled and static bits unset, clients see no PoW requirement while events start getting rejected under load. Suggest advertising floorBits when pow.enabled (noting the live requirement can be higher) and counting pow.enabled toward restricted_writes.
4. recordAdaptivePowEvent fires before acceptance, so signature-valid events rejected for PoW, blacklist, auth, NIP-05, or dedup all count toward difficulty. Per-pubkey rate limits do not bound the aggregate, so an attacker rotating pubkeys can push every user to ceiling difficulty with cheap unmined spam, and ordinary client retries inflate the gate. If counting rejected load is deliberate (defensible: it is a CPU-cost proxy, signatures were already verified), please document that semantics in CONFIGURATION.md; otherwise record only accepted events.
Minor / nits (inline below)
- CONFIGURATION.md: the four
limits.event.powrows break the table's own alphabetical-order rule. src/@types/settings.ts: TSDoc says "on top of" the static checks, but the feature replaces them while enabled.- CONFIGURATION.md: "ignored while pow.enabled is true" is only true for the client path;
static-mirroring-worker.tsstill enforces static bits for mirrored events. Worth a parenthetical for mirror operators.
Verified fine
EWMA cold-start (the lastEventAt = 0 first-call trick), inclusive-at-target floor, Math.ceil rounding (defender-favorable), ceiling clamp, per-worker in-process design (deliberate and disclosed in the changeset), config plumbing and defaults, and the new unit tests otherwise pin real boundaries. The test gap that let #1/#2 through CI: no time-spaced events and no floor-over-ceiling case.
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Description
nostream already has basic NIP-13 PoW verification it checks leading zero bits on event IDs and pubkeys against a static
minLeadingZeroBitsconfig value. The difficulty is fixed, so it's either too low (useless during a spam flood) or too high (annoying during quiet periods).This adds an adaptive difficulty layer that scales the required PoW between a configured floor and ceiling based on the observed event rate, tracked per worker process using the same EWMA shape already used by the relay's rate limiter, kept purely in-process rather than Redis-backed.
When enabled, the computed difficulty replaces the static
eventId/pubkeyminLeadingZeroBitschecks entirely one difficulty applied uniformly to both. Disabled by default.static-mirroring-worker.ts's own PoW check is left untouched it evaluates events already accepted by an upstream relay, a different trust context the adaptive/load-aware framing doesn't obviously apply to.Related Issue
Closes #755
Motivation and Context
A fixed PoW difficulty can't respond to actual relay load it's either a no-op during quiet periods or an unnecessary burden on legitimate users during a spam flood. This lets operators set a reasonable floor/ceiling and let the relay adjust automatically instead of hand-tuning one static number.